Skip to content

S02-05 面向对象-代码块

[TOC]

代码块

在 Java 中,代码块 (Code Block) 指的是用一对花括号 {} 包围起来的代码片段

根据位置、修饰符的不同,代码块主要分为四种。它们在 Java 面试和实际开发中(尤其是涉及到对象初始化顺序时)非常重要。

基本语法

java
[修饰符] {
  // 逻辑语句
};
  • 修饰符static,可选,按照是否有 static 修饰符分为两类:
    • 普通代码块:没有 static 修饰
    • 静态代码块:有 static 修饰
  • 逻辑语句:可以是任意逻辑语句,包括输入、输出、方法调用、循环、判断等。
  • ;:尾部的分号,可以省略。

代码块分类

类型关键字位置执行次数执行时机主要用途
普通代码块方法内随方法调用方法被调用且运行到该位置时限制变量作用域
构造代码块类中每次 new创建对象时,构造方法之前抽取构造方法共性逻辑
静态代码块static类中仅 1 次类加载时初始化静态资源
同步代码块synchronized方法内随调用获取锁成功时线程安全控制

普通代码块

好的,我们来深入探讨 Java 中的普通代码块(通常也被称为局部代码块)。

虽然在日常开发中,我们最常讨论的是静态代码块或构造代码块,但普通代码块作为最基础的代码组织形式,有着它特定的应用场景和规则。

概述

普通代码块(Local Block,局部代码块) 是指直接在方法内部构造器内部,或者在控制语句(如 ifforwhile)内部使用一对大括号 {} 括起来的代码段。

  • 位置:方法体内部。
  • 执行时机:遵循代码的顺序执行原则,当程序的控制流从上往下运行到该代码块时,就会执行。

应用场景

普通代码块最核心的作用只有一个:限制局部变量的生命周期和作用域

  1. A. 控制变量作用域,防止污染

    {} 内声明的变量,被称为局部变量。一旦代码执行跳出这个 {},这些变量就会立刻失效(出栈),不能再被外部访问。这使得我们可以非常精确地控制变量的存活范围

    代码示例:

    java
    public class LocalBlockDemo {
      public void processData() {
        System.out.println("方法开始");
    
        // 1. 普通代码块
        {
          int tempId = 1001;
          String tempData = "临时数据";
          System.out.println("处理临时数据: " + tempId + " - " + tempData);
        } // 2. tempId 和 tempData 在这里生命周期结束
    
        // System.out.println(tempId); // 3. 这里会编译报错:找不到符号
    
        System.out.println("方法结束");
      }
    }
  2. B. 避免同一方法内的变量名冲突

    如果一个方法非常长(虽然不推荐写长方法),你可能需要在不同的阶段使用相同含义的变量名。使用普通代码块可以将它们隔离开来。

    代码示例:

    java
    public void calculate() {
      // 阶段 1:计算 A
      {
        int result = 10 * 20;
        System.out.println("阶段1结果:" + result);
      }
    
      // 阶段 2:计算 B (可以毫无顾忌地再次使用 result 作为变量名)
      {
        int result = 50 + 60;
        System.out.println("阶段2结果:" + result);
      }
    }
  3. C. 内存优化(历史原因)

    在早期的 Java 虚拟机(JVM)中,及早结束无用变量的生命周期,可以让这部分内存(主要是栈帧中的局部变量表槽位)尽早被复用,或者让其中引用的堆内存对象尽早符合垃圾回收(GC)的条件。

    • 现实情况:现代 JVM 的 JIT(即时编译器)已经非常智能,能够进行极佳的逃逸分析和内存优化。因此,单纯为了“省内存”而在日常业务代码中刻意使用普通代码块的情况已经很少见了。

变量遮蔽

需要注意的“坑”:变量遮蔽(Shadowing)规则:

在 Java 中,普通代码块不能重新定义与其外部作用域中已经存在的同名变量。这与 C/C++ 等语言不同。

错误示例:

java
public void testShadowing() {
  int x = 10;

  {
    // int x = 20; // 编译报错:已在方法 testShadowing() 中定义了变量 x
    int y = 20;    // 这是允许的
  }
}

现代开发中的实际应用建议

现代开发中的实际应用建议:

在现代 Java 开发规范(如《阿里巴巴 Java 开发手册》)和 Clean Code(整洁代码)思想中,不建议在一个方法中大量使用普通的局部代码块

  • 替代方案:如果你发现需要用代码块来隔离一大段逻辑和变量,这通常是一个“代码坏味道”(Code Smell),暗示你的方法太长了。更好的做法是将这个局部代码块提取成一个独立的私有方法(Extract Method)。这样不仅作用域被隔离了,代码的可读性和可重用性也会大幅提升。

普通代码块的逻辑相对直白,它本质上就是对作用域边界的一次声明。

构造代码块@

我们继续深入探讨 Java 中非常有特色的构造代码块(在官方文档中常被称为实例初始化块 )。

相比于普通代码块主要用于限制变量作用域,构造代码块的核心舞台在于对象的初始化阶段

概述

构造代码块(Instance Initialization Block,实例初始化块) 是直接定义在类内部、和成员方法和属性平级、没有任何修饰符(特别是没有 static 关键字)的一对大括号 {}

  • 位置:类中,方法之外,和成员方法和属性平级。
  • 执行时机:每次使用 new 关键字创建类的实例(对象)时都会执行。它必定优先于类的构造方法执行。

应用场景

  1. A. 提取公共初始化逻辑(代码复用)

    如果你的类有多个重载的构造方法,并且这些构造方法内部有一段完全相同的初始化代码(例如:验证参数、记录日志、初始化一些复杂的非静态成员变量),你可以把这段重复代码提取到构造代码块中,从而避免代码冗余。

    代码示例:

    java
    public class User {
      private String name;
      private int age;
    
      // 构造代码块:提取公共逻辑
      {
        System.out.println("[系统日志] 正在初始化新的 User 对象,分配唯一标识...");
        // 假设这里有一段复杂的公共校验或初始化逻辑
      }
    
      public User() {
        System.out.println("-> 执行无参构造方法");
      }
    
      public User(String name, int age) {
        this.name = name;
        this.age = age;
        System.out.println("-> 执行有参构造方法");
      }
    }

    当调用 new User()new User("Alice", 25) 时,控制台都会首先打印 [系统日志]...,然后再打印各自构造方法的专属输出。

  2. B. 匿名内部类的初始化

    匿名内部类没有类名,因此无法定义显式的构造方法。如果你需要在创建匿名内部类实例时进行初始化操作(比如给集合填充初始数据),构造代码块是你唯一的选择。这也是一种非常经典的用法(常被称为双大括号初始化)。

    代码示例:

    java
    List<String> list = new ArrayList<String>() {
      // 这是一个匿名内部类中的构造代码块
      {
        add("Apple");
        add("Banana");
        add("Orange");
      }
    };

底层原理

底层原理:编译器施展的“小魔法”:

为什么构造代码块会优先于构造方法执行?这其实是 Java 编译器(javac)在幕后做的工作。

在编译阶段,编译器会将所有构造代码块中的代码,按顺序原封不动地复制到每一个构造方法的开始处(确切地说,是插入到 super() 调用之后,也就是父类初始化完成后)。

因此,在 JVM 真正运行的时候,构造代码块的内容本质上已经成为了构造方法的一部分,并且排在构造方法自身逻辑的前面。

规则细节

需要注意的细节规则:

  • 执行次数每创建一个对象,就执行一次。创建 100 个对象,它就执行 100 次。这与只在类加载时执行一次的静态代码块形成了鲜明对比。
  • 多个代码块的顺序:如果一个类中有多个构造代码块,它们会按照在源文件中从上到下的顺序依次执行。
  • 与成员变量的优先级:构造代码块和非静态成员变量的显式赋值是平级的。它们严格按照在代码中出现的先后顺序执行。

顺序示例:

java
public class OrderDemo {
  // 1. 成员变量显式赋值
  private int a = 10;

  // 2. 构造代码块 (因为写在变量后面,所以后执行)
  {
    System.out.println("构造代码块执行,此时 a = " + a);
    a = 20;
  }

  // 3. 构造方法 (最后执行)
  public OrderDemo() {
    System.out.println("构造方法执行,此时 a = " + a);
    a = 30;
  }
}
// 最终对象的 a 属性值是 30。

构造代码块虽然在日常 CRUD(增删改查)业务中不如静态代码块常见,但理解它的底层机制对于阅读优秀的开源框架源码极其重要。

静态代码块@

在 Java 中,静态代码块是使用频率非常高、也是面试中最常被考查的代码块之一。它与类的加载机制息息相关,扮演着“全局初始化”的重要角色。

概述

静态代码块(Static Code Block) 是定义在类中、方法之外,并且由 static 关键字修饰的一段被 {} 包裹的代码。

  • 位置:类内部,且必须带有 static 关键字。
  • 语法static { ... }

核心特征与执行时机

核心特征与执行时机:

理解静态代码块的关键在于“类加载”这三个字。

  • 执行时机运行时的类加载阶段执行,当类第一次被 Java 虚拟机(JVM)加载到内存中,并进行初始化(Initialization)阶段时,静态代码块就会被执行。它绝对优先于任何对象的创建(甚至优先于构造代码块和普通方法)。
  • 执行次数:在整个 JVM 的生命周期内,一个类通常只会被加载一次,因此静态代码块只执行一次,无论你之后 new 了多少个该类的对象。

类加载时机@

主动引用

要理解类什么时候被加载,首先需要明确 JVM 的一个核心机制:懒加载(Lazy Loading)。JVM 不会在启动时把所有相关的 .class 文件一股脑儿全塞进内存,而是“按需加载”。

在 Java 虚拟机规范中,严格规定了只有在发生“主动引用(Active Use)”时,才会触发类的加载和初始化(在这之前,必须完成加载、验证、准备阶段)。

类在什么时候会被初始化

  1. 创建对象实例时:执行 new 创建一个对象实例时类会被加载,并且只会加载一次

    java
    class Student {
      static {
        System.out.println("【时机1】Student 类被加载并初始化了!");
      }
    }
    
    public class TriggerDemo1 {
      public static void main(String[] args) {
        System.out.println("准备执行 new 操作...");
        Student stu = new Student(); // 触发加载
      }
    }
  2. 创建子类对象实例时,父类也会被加载

    java
    class Animal {
      static {
        System.out.println("【时机5 - 父类】Animal 类被加载并初始化了!");
      }
    }
    
    class Dog extends Animal {
      static {
        System.out.println("【时机5 - 子类】Dog 类被加载并初始化了!");
      }
    }
    
    public class TriggerDemo5 {
      public static void main(String[] args) {
        System.out.println("准备实例化子类...");
        Dog dog = new Dog(); // 这里会先触发 Animal 加载,再触发 Dog 加载
      }
    }
  3. 使用类的非 final 静态成员时

    java
    // 静态属性
    class Config {
      public static int timeout = 5000;
    
      static {
        System.out.println("【时机2】Config 类被加载并初始化了!");
      }
    }
    
    public class TriggerDemo2 {
      public static void main(String[] args) {
        System.out.println("准备读取静态变量...");
        int t = Config.timeout; // 访问静态属性,触发加载
      }
    }
    java
    // 静态方法
    class MathUtils {
      static {
        System.out.println("【时机3】MathUtils 类被加载并初始化了!");
      }
    
      public static void calculate() {
        System.out.println("执行计算...");
      }
    }
    
    public class TriggerDemo3 {
      public static void main(String[] args) {
        System.out.println("准备调用静态方法...");
        MathUtils.calculate(); // 访问静态方法,触发加载
      }
    }
  4. 使用反射机制(Class.forName())时

    当使用 java.lang.reflect 包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。这在加载数据库驱动等场景极其常见。

    java
    class DatabaseDriver {
      static {
        System.out.println("【时机4】DatabaseDriver 类被反射机制加载并初始化了!");
      }
    }
    
    public class TriggerDemo4 {
      public static void main(String[] args) throws ClassNotFoundException {
        System.out.println("准备使用反射加载类...");
        Class<?> clazz = Class.forName("DatabaseDriver"); // 使用反射机制,触发加载
      }
    }
  5. JVM 启动时标明的启动类 (包含 main 方法的类)

    当虚拟机启动时,用户需要指定一个要执行的主类(包含 public static void main(String[] args) 的那个类),虚拟机会先初始化这个主类。

    注意:在 JDK 8 之后,如果一个接口定义了 default 默认方法,当它的实现类被初始化时,该接口也会在实现类之前被初始化。这也是一个较为边缘但真实存在的触发时机。

    java
    public class MainApp {
    
      static {
        System.out.println("【时机6】包含 main 方法的主类 MainApp 在启动时被最先加载!");
      }
    
      public static void main(String[] args) {
        System.out.println("main 方法开始执行...");
      }
    }
    // 运行该类,控制台会先打印静态代码块的内容,再打印 main 方法的内容。

被动引用

被动引用:在 Java 虚拟机规范中明确规定:所有针对类的引用,如果不是“主动引用”,统统被称为“被动引用”。 被动引用最核心的特征就是:它不会触发类的初始化(即不会执行类的静态代码块和静态变量赋值)

下面为您详细拆解面试中最常考察的 3 种经典的“被动引用”场景,并附上代码验证:

  1. 通过子类引用父类的静态字段

    当通过子类来访问父类中定义的静态字段时,只有真正声明这个字段的类(也就是父类)才会被初始化,而子类不会被初始化

    • 原理解析:对于静态字段,JVM 认为谁定义了它,就初始化谁。子类只是充当了一个“调用通道”的角色。
    java
    class SuperClass {
      static int value = 123;
      static {
        System.out.println("【父类】SuperClass 被初始化了!"); // 有被打印
      }
    }
    
    class SubClass extends SuperClass {
      static {
        System.out.println("【子类】SubClass 被初始化了!"); // 没有被打印
      }
    }
    
    public class PassiveRefDemo1 {
      public static void main(String[] args) {
        System.out.println("准备通过子类访问父类的静态变量...");
        // 这里的调用看似用到了 SubClass,但实际上访问的是 SuperClass 的变量
        System.out.println(SubClass.value);
      }
    }
  2. 通过数组定义来引用类

    当你声明一个某个类的数组时,并不会触发该类的初始化。

    • 原理解析:当你执行 new SuperClass[10] 时,JVM 并没有去实例化 10 个 SuperClass 对象,而是由 JVM 动态生成了一个专门用来装载这些引用的新类型(类似于 [LSuperClass; 的数组类),并在内存中开辟了一块连续的空间用来存放 10 个 null 引用。因此,真正的 SuperClass 并没有被触碰
    java
    class SuperClass {
      static {
        System.out.println("【父类】SuperClass 被初始化了!"); // 没有被打印
      }
    }
    
    public class PassiveRefDemo2 {
      public static void main(String[] args) {
        System.out.println("准备定义 SuperClass 的数组...");
        // 仅仅是分配了数组空间,并没有实例化具体的对象
        SuperClass[] arr = new SuperClass[10];
        System.out.println("数组定义完毕,长度为: " + arr.length);
      }
    }
  3. 引用类的常量 (static final)

    当一个类访问另一个类中被 static final 修饰、且在编译期就能确定其值的常量时,不会触发定义该常量的类的初始化。

    • 原理解析(极其重要,常考考点):这在底层叫做常量传播优化。在 Java 代码编译成 .class 字节码文件的阶段,编译器发现这个常量的值是固定不变的(比如字符串 "hello" 或数字 100),就会直接把这个常量的值,复制一份存放到调用类(例子中的 PassiveRefDemo3)自己的常量池中
    • 这就意味着,在 JVM 真正运行的时候,调用类根本不需要再去联系那个定义常量的类了,两者在运行期彻底“解绑”。
    java
    class ConstClass {
      // static final 修饰的编译期常量
      public static final String HELLO_WORLD = "Hello World!";
    
      static {
        System.out.println("【常量类】ConstClass 被初始化了!"); // 没有被打印
      }
    }
    
    public class PassiveRefDemo3 {
      public static void main(String[] args) {
        System.out.println("准备访问 ConstClass 的常量...");
        // 实际上这个字符串在编译期就已经放进 PassiveRefDemo3 的常量池了
        System.out.println(ConstClass.HELLO_WORLD);
      }
    }

总结提示:

严格来说,“被动引用”并不代表该类在 JVM 中连“加载(Loading)”阶段都没有经历。在某些虚拟机的具体实现中,被动引用可能会触发类的“加载”,但它绝对不会触发类的“初始化(Initialization)”(也就是不会执行静态代码块和静态赋值操作)。在日常开发排查问题时,我们主要关注的就是它没有执行初始化逻辑

应用场景

典型应用场景:

静态代码块的核心作用是初始化类的静态成员(静态变量),或者执行一些只需要在系统启动时执行一次的准备工作

  1. A. 复杂的静态变量初始化

    有时候,给静态变量赋初始值不是一句简单的等式就能完成的,可能需要逻辑判断、异常处理或者多步计算。静态代码块提供了执行这些复杂逻辑的空间。

    代码示例(初始化静态的 Map 集合):

    java
    import java.util.HashMap;
    import java.util.Map;
    
    public class ConfigManager {
      public static Map<String, String> settings;
    
      // 静态代码块:用于执行复杂的静态初始化逻辑
      static {
        System.out.println("-> 正在加载系统全局配置...");
        settings = new HashMap<>();
        settings.put("timeout", "3000");
        settings.put("max_retries", "3");
        // 假设这里还包含从配置文件读取数据的逻辑,可能会抛出异常
      }
    }
  2. B. 加载底层驱动或本地库

    在很多经典的 Java 框架中(如 JDBC),通常会在静态代码块中注册驱动,或者使用 System.loadLibrary() 加载 C/C++ 编写的底层动态链接库

    代码示例(模拟 JDBC 驱动加载):

    java
    public class MyDatabaseDriver {
      // 静态代码块:类加载时自动将自己注册到驱动管理器中
      static {
        System.out.println("-> 注册数据库驱动...");
        // DriverManager.registerDriver(new MyDatabaseDriver());
      }
    }

    提示:当你执行 Class.forName("MyDatabaseDriver") 时,即使没有创建对象,也会触发类的加载,从而执行这段静态代码块。

关键访问限制@

因为静态代码块在类加载时就执行了,而那时候对象根本还没创建出来,所以它有一些严格的限制(静态的限制):

  1. 只能访问静态成员:静态代码块内部只能直接访问该类的其他静态变量和静态方法。

  2. 绝对不能访问实例成员:不能访问非静态的变量和非静态的方法。

  3. 不能使用 thissuper 关键字:因为这两个关键字都指向具体的对象实例,而类加载时实例还不存在。

错误示例:

java
public class ErrorDemo {
  // 属性
  private static int staticVar = 20;
  private int instanceVar = 10;

  // 方法
  private static void staticMethod() { System.out.println("静态方法") }
  private void instanceMethod() { System.out.println("普通方法") }

  // 静态代码块
  static {
    System.out.println(staticVar); // ✅ 正确:访问静态属性
    System.out.println(staticMethod); // ✅ 正确:访问静态方法

    // System.out.println(instanceVar); // ❌ 编译报错:无法从静态上下文中引用非静态变量
    // System.out.println(instanceMethod); // ❌ 编译报错:无法从静态上下文中引用非静态方法
    // System.out.println(this.staticVar); // ❌ 编译报错:无法从静态上下文中引用 'this'
  }
}

执行顺序

执行顺序(与静态变量):

如果一个类中有多个静态变量显式赋值和多个静态代码块,它们是平级的,JVM 会严格按照它们在源代码中从上到下的顺序依次执行。

java
public class StaticOrderDemo {
  // 1. 静态变量显式赋值
  public static int value = 1;

  // 2. 静态代码块
  static {
    System.out.println("执行静态代码块,此时 value = " + value);
    value = 2; // 修改静态变量的值
  }

  public static void main(String[] args) {
    System.out.println("main方法执行,最终 value = " + value);
  }
}
// 输出:
// 执行静态代码块,此时 value = 1
// main方法执行,最终 value = 2

同步代码块

我们终于来到了 Java 代码块中最具挑战性、也是并发编程中最核心的部分:同步代码块(Synchronized Block)

如果说前面三种代码块(局部、构造、静态)是为了组织代码和初始化数据,那么同步代码块完全是为了解决“多线程安全问题”而生的

概述

在多线程环境下,当多个线程同时访问并修改同一个共享资源(比如同一个变量、同一个文件)时,极易引发数据混乱(竞态条件)。为了保证数据的正确性,Java 提供了 synchronized 关键字。

同步代码块(Synchronized Block) 就是被 synchronized 关键字和一对大括号 {} 包裹起来的代码段。

  • 位置:定义在方法内部
  • 核心思想:排队机制。它确保在同一时刻,最多只能有一个线程能够进入这部分代码执行。其他试图进入的线程必须在外面阻塞等待,直到当前线程执行完毕并释放“锁”。

基本语法与锁对象

基本语法:

同步代码块的语法比其他代码块多了一个必须要提供的参数:锁对象

java
synchronized (锁对象) {
  // 需要被同步的代码(临界区)
  // 通常是操作共享数据的代码
}

锁对象(Monitor)

在 Java 中,任何一个对象都可以作为这把“锁”(底层称为 Monitor 对象)。当线程执行到 synchronized 时,它会去检查这个对象有没有被别的线程“锁住”

  • 如果没有,它就拿走锁,进入代码块执行。
  • 如果有,它就在代码块外面排队等待(阻塞状态)。

常用锁对象

常用的三种“锁”对象:

根据保护的资源不同,我们通常会选择不同的锁对象:

this

A. 使用 this 作为锁(当前对象锁):

如果多个线程共享的是同一个实例对象,最常见的就是用 this

java
public class BankAccount {
  private int balance = 1000;

  public void withdraw(int amount) {
    // ... 一些不需要同步的准备工作 ...

    synchronized (this) { // 锁定当前 BankAccount 实例
      if (balance >= amount) {
        balance -= amount;
        System.out.println(Thread.currentThread().getName() + " 取款成功,余额: " + balance);
      } else {
        System.out.println("余额不足");
      }
    }
  }
}

自定义对象(推荐)

B. 使用自定义对象作为锁(细粒度锁):

这是最推荐的做法。如果一个类里有两段完全不相干的同步逻辑(比如一个是修改账户 A,一个是修改账户 B),用 this 会导致它们互相阻塞。这时应该专门创建一个对象充当锁,强烈建议使用 final 修饰,防止锁被中途替换。

java
public class TicketSystem {
  private int tickets = 100;
  // 1. 专门创建一个对象充当锁,强烈建议使用 final 修饰,防止锁被中途替换!
  private final Object lock = new Object();

  public void sellTicket() {
    // 2. 只锁定特定的业务逻辑
    synchronized (lock) {
      if (tickets > 0) {
        tickets--;
        System.out.println("卖出一张票,剩余: " + tickets);
      }
    }
  }
}

类名.class

C. 使用 类名.class 作为锁(全局/类锁):

如果我们要保护的共享资源是静态变量(属于类的,被所有对象共享),那么用 this 或普通的实例对象是锁不住的,必须使用该类的 Class 对象。

java
public class GlobalCounter {
  // 1. 共享资源是静态变量
  private static int globalCount = 0;

  public static void increment() {
    // 2. 锁定整个类
    synchronized (GlobalCounter.class) {
      globalCount++;
    }
  }
}

对比同步方法

为什么要用同步代码块(对比同步方法)

除了同步代码块,Java 还可以直接在方法签名上加 synchronized(即同步方法)。为什么还要用同步代码块呢?

  • 更细的控制粒度(性能更好):如果一个方法有 100 行代码,但只有其中 5 行涉及修改共享数据。如果使用同步方法,整个 100 行代码都会被锁住,其他线程等待时间过长;如果使用同步代码块,只锁那 5 行,剩下的 95 行代码各个线程依然可以并发执行,大大提升了系统吞吐量。

注意事项

注意事项:

  1. 锁对象不能为 null:如果传入 synchronized (null),运行时会直接抛出 NullPointerException

  2. 避免使用 String 常量和基本类型包装类作为锁:因为由于 JVM 字符串常量池和包装类缓存(如 Integer 的 -128 到 127)的存在,你以为不同的锁,可能在底层是同一个对象,从而引发莫名其妙的死锁或阻塞。永远优先使用 new Object()

代码块执行顺序

这是一个非常经典且高频的 Java 面试题!理解了这个执行顺序,就等于彻底打通了 Java 类加载机制和对象生命周期的“任督二脉”。

当存在父子类继承关系时,代码块和构造方法的执行顺序遵循一个核心原则:先静态后实例,先父类后子类

我们可以把整个过程分为两个阶段:类加载阶段(只发生一次)和对象创建阶段(每次 new 都会发生)。

核心执行顺序

核心执行顺序(黄金法则):

当你第一次创建子类对象(例如执行 new Child())时,完整的执行顺序如下:

  1. 父类静态代码块和静态属性(父类加载时执行。优先级一样时,按定义顺序执行)

  2. 子类静态代码块和静态属性(子类加载时执行。优先级一样时,按定义顺序执行)

  3. 父类构造代码块和非静态属性(准备初始化父类空间。优先级一样时,按定义顺序执行)

  4. 父类构造方法(完成父类初始化)

  5. 子类构造代码块和非静态属性(准备初始化子类空间。优先级一样时,按定义顺序执行)

  6. 子类构造方法(完成子类初始化)

重点提示:如果你紧接着创建第二个子类对象,步骤 1 和 2(静态代码块)将不再执行,只会依次执行步骤 3、4、5、6。

经典代码验证:

空谈不如看代码,我们用一段完整的代码来印证这个法则:

java
// 父类
class Parent {
  static { System.out.println("1. 父类 - 静态代码块"); }

  { System.out.println("3. 父类 - 构造代码块"); }

  public Parent() {
    System.out.println("4. 父类 - 无参构造方法");
  }
}

// 子类继承父类
class Child extends Parent {
  static { System.out.println("2. 子类 - 静态代码块"); }

  { System.out.println("5. 子类 - 构造代码块"); }

  public Child() {
    // 这里隐藏了一个 super();
    System.out.println("6. 子类 - 无参构造方法");
  }
}

// 测试类
public class InitializationOrderDemo {
  public static void main(String[] args) {
    System.out.println("--- 第一次实例化子类 ---");
    new Child();

    System.out.println("\n--- 第二次实例化子类 ---");
    new Child();
  }
}

控制台输出结果:

text
--- 第一次实例化子类 ---
1. 父类 - 静态代码块
2. 子类 - 静态代码块

3. 父类 - 构造代码块
4. 父类 - 无参构造方法
5. 子类 - 构造代码块
6. 子类 - 无参构造方法

--- 第二次实例化子类 ---
3. 父类 - 构造代码块
4. 父类 - 无参构造方法
5. 子类 - 构造代码块
6. 子类 - 无参构造方法

底层原理解析

为什么是这个顺序(底层原理解析):

要彻底记住这个顺序,死记硬背是不够的,理解其背后的底层逻辑会让你茅塞顿开:

  • 为什么静态最先执行?且父类优先子类?

    JVM 在遇到 new Child() 时,会先去方法区找 Child 类的元数据。如果没加载,就会触发类加载。因为 Child 继承自 Parent,JVM 规定加载子类前必须先加载其父类。类加载阶段会执行 static 代码块,所以父类静态块先于子类静态块执行。加载完成后,静态部分就“固化”在内存中了,以后再也不用管了。

  • 为什么父类的构造代码块/构造方法,先于子类执行?

    子类的构造方法中,第一行永远隐藏着一句隐式的 super();(除非你显式调用了带参的 super(...))。这保证了在初始化子类特有属性之前,父类的状态必须先被完全初始化。

  • 为什么构造代码块先于构造方法?

    正如我们之前聊过的,Java 编译器在编译时,会把“构造代码块”里的代码全部复制,悄悄塞进所有构造方法的最前面(紧跟在 super() 之后)。所以它自然就在构造方法的主体逻辑之前执行了。

java
class Father {}

class Child {

  { // 构造代码块 }

  public Child() {
    // super();  // 1. 构造器最开始隐式包含 super(),指向父类引用
    // 2. 编译时,会将构造代码块中的代码复制到此处
    // 3. 其他代码逻辑
  }
}